#!/usr/bin/env python
"""
cvsnup [<options>] [DIRECTORIES ...]

Find any unupdated files in the local CVS repository and report them.
Homepage: http://cvsxxdiff.googlecode.com/
"""

# Written by Michalis Giannakidis.
# Use it at your own risk.

# A companion programm to cvsxxdiff
# ex: cvsxxdiff `cvsnup -qs -ME` 
#
# version 0.1: first version realeased to the public
# version 0.2: New features such as status filtering options added.
# version 0.3: Speed up by avoiding redundand walks.
#              Format of this file is from now on: soft tabs of size 4
# version 0.4: color output
# version 0.5: fix bug in output for filenames that exist more than once in the 
#              CVS tree
# version 0.6: header separator of eatch status group, now includes the command 
#              line filter switch
# version 1.0.0: some code changes to make the same with cvsxxdiff v0.9
# version 1.0.1: use popen3 to get 'cvs status'
# version 1.0.2: Make utility able to operate on specific files. eg:
#                cvsnup argiris/Deckmenu.c.
#                Support generation of changelogs.
# version 1.0.3: Generate patchset output for localy modified files.
# version 1.0.4: Proper resolution of Locally Added files
# version 0.1.5: Print changelog without initial indentation
#                Print patchset revisions similarly to cvscommit


__version__ = '0.1.5'
__author__ = 'Michalis Giannakidis <mgiannakidis@gmail.com>'

import os
import sys
import re
import optparse

#-------------------------------------------------------------------------------
#
def cprint(field, color = 'white'):
	"""Return the 'field' in collored terminal form"""

	Term_colors = {
		'black':30,
		'red':31,
		'green':32,
		'yellow':33,
		'blue':34,
		'magenta':35,
		'cyan':36,
		'white':37, 
	}
	field = '[01;%dm%s[00m' % ( Term_colors[color], str(field) )
	return field

#-------------------------------------------------------------------------------
#
def get_full_user_name(user_name):

	try:
		import pwd
		
		return pwd.getpwnam( user_name )[4].split(',')[0]
	except ImportError:
		return ''
	except KeyError:
		return ''

#-------------------------------------------------------------------------------
def get_cvsroot_path(dir):
	"""
	Extract the path from the CVS/Root file
	and the module name from CVS/Repository
	"""

	crt, crp = None, None
	if not dir:
		dir = ''

	cvsroot = open(os.path.join(dir, 'CVS', 'Root')).readline()
	cvsroot = cvsroot.rstrip()
	if cvsroot[-1] == os.path.sep: cvsroot = cvsroot[:-1]

	i = 0
	while cvsroot[i]:
		if cvsroot[i] == os.path.sep:
			crt =  cvsroot[i:]
			break
		i = i + 1

	cvsrepos = open( os.path.join(dir, 'CVS', 'Repository') ).readline()
	crp = cvsrepos.rstrip()

	return crt, crp

#-------------------------------------------------------------------------------
#
class Cvsfilestatus:
	def __init__(self):
		self.short_filename = ''
		self.status = ''
		self.filename = ''
		self.rep_revision = ''
		self.work_revision = ''
	def __repr__(self):
		return ' '.join( (self.short_filename, self.status, \
			self.filename, self.rep_revision, self.work_revision) )

#-------------------------------------------------------------------------------
#
def parse_cvs_status(filesdirs):

	try:
		CvsRoot, CvsRepository = get_cvsroot_path(None)
	except IOError:
		raise SystemExit('Error: You are not in a CVS managed directory.\n\
See cvsnup -h for details')
	# transform a file 'Repository revision' to a local filename:
	# eg: /mnt/raid_disk/ansa10_cvsroot/ansa10/argiris/a_rbody.c,v -> argiris/a_rbody.c
	abs_file_prefix = os.path.sep.join([CvsRoot, CvsRepository, ''])

	match_status = re.compile('File:\s*([^\s]+)\s*Status: (.*)')
	match_status_nf = re.compile('File: no file\s*([^\s]+)\s*Status: (.*)')
	match_repos = re.compile( 
				'Repository revision:\s([\d\.]*)\s*'+abs_file_prefix+'(.*),v'
							 )
	match_work = re.compile('Working revision:\s([\d\.]*)')

	files = []

	sin, sout, serr = os.popen3('cvs -q status '+filesdirs)
	for l in sout:
	# match Status:, then Repository revision
		m = match_status.search(l) or match_status_nf.search(l)
		if m:

			cvsfilestat = Cvsfilestatus()
			files.append(cvsfilestat)

			cvsfilestat.short_filename = m.group(1)
			cvsfilestat.filename = cvsfilestat.short_filename
			cvsfilestat.status = m.group(2)
	
		mr = match_repos.search(l)
		if mr:
			cvsfilestat.filename = mr.group(2).strip()
			cvsfilestat.rep_revision = mr.group(1).strip()
			continue

		mw = match_work.search(l)
		if mw:
			cvsfilestat.work_revision = mw.group(1).strip()
			continue

	map(sys.stderr.write, serr)

	sin.close()
	sout.close()
	serr.close()

	files_len = len(files)
	files = filter(lambda x: x.status != 'Locally Added', files)
	if files_len != len(files):
		# we need to do extra work to determine locally added files.
		match_file = re.compile('A (.*)')
		sin, sout, serr = os.popen3('cvs -q -n update '+filesdirs)
		for l in sout:
			# match: A filename
			m = match_file.search(l)
			if m:
				cvsfilestat = Cvsfilestatus()
				files.append(cvsfilestat)

				cvsfilestat.short_filename = m.group(1)
				cvsfilestat.filename = m.group(1)
				cvsfilestat.status = 'Locally Added'

		map(sys.stderr.write, serr)

		sin.close()
		sout.close()
		serr.close()


	return files

#-------------------------------------------------------------------------------
#
def collect_unupdated_files( stat_filesdirs, status_intrested_in ):
	"""
	Collect all files that are not updated (compared to the repository).
	"""
	assert stat_filesdirs

	filesdirs = ' '.join(map(str, stat_filesdirs))
	files = parse_cvs_status(filesdirs)

	collected_files = {}
	for entry in files:
		if len(status_intrested_in):
			if not entry.status in status_intrested_in:
				continue
		else:
			if entry.status == 'Up-to-date':
				continue

		collected_files.setdefault(entry.status, []).append(entry)

	return collected_files

#-------------------------------------------------------------------------------
#
def print_unupdated_files(collected_files, singleline, quiet):

	status_collection = collected_files.keys()
	status_collection.sort()
	for status in status_collection:
		entries = collected_files[status]
		entries.sort(lambda x, y: cmp(x.filename, y.filename) )
		if not quiet:
			filter = 'Unknown'
			for arg, messg, opt in status_flags:
				if messg == status:
					filter = arg[1]
					break
			print '%s (%s) (%s):' % \
			   (cprint('>>> %s' % status, 'green'), cprint(len(entries), 'red'),
				cprint(filter, 'yellow' ) )
		if singleline:
			for entry in entries:
				print  entry.filename,
		else:
			for entry in entries:
				print '%s' % ( entry.filename )
		if not quiet:
			if status != status_collection[-1]: print


#-------------------------------------------------------------------------------
#
def print_log(seq, show_revisions):
	"""Print eatch item of seq seperatly, but all in the same line"""

	if not len(seq):
		return

	print ''
	files_str = '>>> files (%d): ' % len( seq[0] )
	if show_revisions == True:
		diff_rev = []
		for f, r in seq[0]:
			r_prev = get_revision_before(r, 1)
			diff_rev.append( '-r%s -r%s %s' % (r_prev, r, f) )
		files_str = files_str + ', '.join(map(str, diff_rev))
	else:
		for f, r in seq[0]:
			files_str = files_str + ' %s' % (f,)

	print cprint(files_str, 'cyan')
	print cprint('>>> date: ', 'green') +cprint( seq[1], 'red'),
	print cprint('author: ', 'green'), 
	real_name = get_full_user_name( seq[2] )
	if len(real_name):
		print cprint( '%s aka' % (real_name ), 'red' ),
	print cprint( seq[2], 'red')
	print '\n'.join(map(str, seq[3:]))


#-------------------------------------------------------------------------------
#
def get_revision_before(rev, back):
	"""Do math arithmetics to get a revision as 'back' versions before"""

	r = rev.split('.')

	r[-1] = str(int(r[-1]) - back)

	# we have reached the start of revision history... 
	# (propably a branch start)
	lr = int(r[-1])
	if lr <= 0:
		r = r[0:-2]
		if lr < 0:
			r[-1] = str(int(r[-1]) + lr)

	r = '.'.join(r)

	return r

#-------------------------------------------------------------------------------
#
def get_revisions_between(r1, r2):
	"""Return a list with the CVS version strings between two releases."""

	rev = []

	l_r1 = r1.split('.')
	l_r2 = r2.split('.')
	min_common = min(len(l_r1), len(l_r2))
	try:
		# we could be on the start of a branch...
		# or comparing unequal things for some reason!

		beg = int(l_r1[min_common - 1])
		end = int(l_r2[min_common - 1])
	except ValueError:
		return rev

	invert_beg_end = False
	if end < beg:
		invert_beg_end = True
	elif end == beg:
		if len(r1) > len(r2):
			invert_beg_end = True

	if invert_beg_end:
		r = r1
		r1 = r2
		r2 = r

	rev.append(r2)
	while True:
		if rev[0] == r1:
			break
		r = get_revision_before(rev[0], 1)
		rev.insert(0, r)

	if invert_beg_end:
		rev.reverse()

	return rev


#-------------------------------------------------------------------------------
#
def get_revision_log(filename, rev):
	"""Get the CVS log of a filename given a revision"""

	log = []
	lines = os.popen('cvs log -r'+rev+' '+filename+' 2>&1').readlines()
	match_log_start = re.compile( '^----------------------------' )
	start_log = False
	for l in lines:
		if start_log == 0:
			m = match_log_start.search(l)
			if m:
				start_log = True
				continue
		if start_log:
			log.append(l.rstrip())

	return log


#-------------------------------------------------------------------------------
#
def print_changelog(collected_files, show_revisions):

	entries = []
	for e in collected_files.values():
		entries.extend(e)

	if not len(entries):
		return
	

	match_author = re.compile('date:\s*([0-9/: +-]+);\s*author:\s*([a-z0-9A-Z_]+);')

	print cprint("\nGenerating ChangeLog... (older -> newer)")
	logs = {}
	for e in entries:
		if e.work_revision == '': # Locally Added
			continue

		if e.work_revision ==  e.rep_revision: # Locally Modified
			log = [ '<undefined>', 'me', 'Locally Modified' ] # date, author, log
			log = '\n'.join(log[1:]).strip()
			future_rev = get_revision_before(e.rep_revision, -1)
			logs.setdefault(log, [ '<undefined>' ]).append( tuple([e.filename, future_rev]))
			continue

		revs = get_revisions_between(e.work_revision, e.rep_revision)
		for r in revs[1:]:
			log = get_revision_log(e.filename, r)
			if not len(log):
				continue
			ma = match_author.search(log[1])
			if ma:
				log[1] = ma.group(2)
			else:
				log[1] = 'unknown author'

			log = '\n'.join(log[1:]).strip()
			logs.setdefault(log, [ ma.group(1) ]).append(tuple([e.filename, e.rep_revision]))
	changes = []
	for log in logs.keys():
		date = logs[log][0]
		files_revisions = logs[log][1:]
		log = log.splitlines()
		changes.append( (date, files_revisions, log) )

	changes.sort(lambda x, y: cmp(x[0], y[0]))
	if not len(changes):
		print 'No unupdated files.'
	else:
		for date, files_revisions, log in changes:
			log.insert(0, date)
			log.insert(0, files_revisions)
			print_log(log, show_revisions)

#-------------------------------------------------------------------------------
#
# From cvs documentation:
# Up-to-date
#      The file is identical with the latest revision in the repository
#      for the branch in use.
# 
# Locally Modified
#      You have edited the file, and not yet committed your changes.
# 
# Locally Added
#      You have added the file with `add', and not yet committed your
#      changes.
# 
# Locally Removed
#      You have removed the file with `remove', and not yet committed
#      your changes.
# 
# Needs Checkout
#      Someone else has committed a newer revision to the repository.
#      The name is slightly misleading; you will ordinarily use `update'
#      rather than `checkout' to get that newer revision.
# 
# Needs Patch
#      Like Needs Checkout, but the CVS server will send a patch rather
#      than the entire file.  Sending a patch or sending an entire file
#      accomplishes the same thing.
# 
# Needs Merge
#      Someone else has committed a newer revision to the repository, and
#      you have also made modifications to the file.
# 
# Unresolved Conflict
#      A file with the same name as this new file has been added to the
#      repository from a second workspace.  This file will need to be
#      moved out of the way to allow an `update' to complete.
# 
# File had conflicts on merge
#      This is like Locally Modified, except that a previous `update'
#      command gave a conflict.  If you have not already done so, you
#      need to resolve the conflict as described in *Note Conflicts
#      example::.
# 
# Unknown
#      CVS doesn't know anything about this file.  For example, you have
#      created a new file and have not run `add'.
# 

#-------------------------------------------------------------------------------
#
status_flags = [ 
	( '-U', 'Up-to-date', 'up_to_date' ),
	( '-M', 'Locally Modified', 'locally_modified' ),
	( '-A', 'Locally Added', 'locally_added' ),
	( '-R', 'Locally Removed', 'locally_removed' ),
	( '-O', 'Needs Checkout', 'needs_checkout' ),
	( '-P', 'Needs Patch', 'needs_patch' ),
	( '-E', 'Needs Merge', 'needs_merge' ),
	( '-F', 'Unresolved Conflict', 'unresolved_conflict' ),
	( '-C', 'File had conflicts on merge', 'file_had_conflicts_on_merge' ),
	( '-K', 'Unknown', 'unknown' ),
]

def parse_options():
	parser = optparse.OptionParser(usage=__doc__.strip(), version="%prog "+ __version__)

	group = optparse.OptionGroup(parser, 
		"Options for filtering files by status",
		"These options allow you to filter for files "
		"you are intrested in, by their cvs status. "
		"If none of these options is specified, "
		"-vU is assumed and all unupdated files are "
		"returned.")

	group.add_option('-v', '--invert-match', action="store_true", 
		dest="inverse_filter", help='Invert the sense of status matching')
	for  arg, messg, opt in status_flags:
		group.add_option(arg, action="store_true", dest=opt, 
			help='Only collect files of status "%s"' % messg)
	parser.add_option_group(group)

	parser.add_option('-l', '--changelog', action="store_true", 
		dest="changelog", help='generate a changelog from the unupdated files')
	parser.add_option('-r', '--show-revisions', action="store_true", 
		dest="revisions", help='show revision numbers for files. Works with -l.')
	parser.add_option('-q', '--quiet', action="store_true", 
		dest="quiet", help='Quiet. Suppress printing of header seperator')
	parser.add_option('-s', '--single-line', action="store_true", 
		dest="singleline", help='Print eatch file group in a single line')


	opts, args = parser.parse_args()


	intrested_in = []
	for  arg, messg, opt in status_flags:
		if opts.__dict__.get(opt):
			intrested_in.append(messg)

	if len(intrested_in):
		if opts.inverse_filter:
			intrested_in_inv = [messg for arg, messg, opt in status_flags]
			map(intrested_in_inv.remove, intrested_in)
			intrested_in = intrested_in_inv
	elif opts.inverse_filter:
		parser.error("Invalid option combination.")


	stat_filesdirs = []
	files_dirs = args
	for f_d in files_dirs:
		if os.path.exists(f_d):
			stat_filesdirs.append(f_d)
		else:
			print 'Unknown argument: '+f_d

	return stat_filesdirs, intrested_in, opts.singleline, opts.quiet, \
		   opts.revisions, opts.changelog


#-------------------------------------------------------------------------------
#
def _cli():

	stat_filesdirs, \
	intrested_in, \
	singleline, \
	quiet, \
	show_revisions, \
	changelog = \
		parse_options()

	collected_files = collect_unupdated_files(stat_filesdirs or ['.'],
							intrested_in)

	print_unupdated_files(collected_files, singleline, quiet)

	if changelog:
		print_changelog(collected_files, show_revisions)


#-------------------------------------------------------------------------------
#
if __name__ == '__main__': _cli()

